Bob Playground


Welcome to Bob‘s blog.


算法的性能分析——空间复杂度与时间复杂度

算法(algorithm)是一组完成特定任务的有穷指令序列。

算法都必须满足以下标准:

  1. 输入:有零或多个由外部提供的输入量。
  2. 输出:至少产生一个输出量。
  3. 确定性:每条指令都有明确的语义。
  4. 有限性:算法的每一条执行路线都能在有限步之内结束。
  5. 有效性:每一条指令都必须足够简单,指令必须是可行的。

空间复杂性

对于程序 P,

  • 固定的空间需求 \(c\):与输入无关的空间需求。

    如指令的存储空间,简单变量、固定大小的结构变量(结构体)和常量的存储空间。

  • 可变的空间需求 \(S_P(I)\):与实例 \(I\) 相关的空间需求。

    对于算法来说,一个实例就是一组特定的输入参数。

空间复杂性 \(S(P)\) 是指程序从开始执行到完成所需的存储空间的数量。

$$S(P) = c + S_P(I)$$

在分析程序的空间复杂性时,通常只关心可变的空间需求 \(S_P(I)\)。

举例

【例一】:\(S_{sum}(n) = 0\)

float sum(float list[], int n)
{
    float tempSum = 0;
    for (int i = 0; i < n, i++)
        tempSum += list[i];
    return tempSum;
}

【例二】:\(S_{rsum}(n) \varpropto n\)

float rsum(float list[], int n)
{
    if (n) return rsum(list, n-1) + list[n-1];
    return 0;
}

这是因为每次迭代,均需要额外的栈上空间。

较早的帧
--- 栈帧 Start ---
被保存的 %ebp
参数2 n
参数1 list
返回地址
--- 栈帧 End ---
被保存的 %ebp
...

每次调用都会产生如上的栈帧。迭代次数约为 n 次。

对于 i386,int(n) 占用 4 byte,指针(被保存的 %ebp、list、返回地址)占用 4 byte,所以 \(S_{rsum}(n) = 16n\) byte。

时间复杂性

程序 \(P\) 运行所需时间 \(T(P)\) 是其编译时间和执行时间的总和。

实际上,真正关注的只是程序的执行时间 \(T_P\)

对于编译时间,一方面其与实例无关,另一方面编译好的程序可以多次执行,而不需再次编译。

程序步

程序步:

  1. 是一个有意义的程序片段;
  2. 执行时间必须与实例特征无关。

第 1 点中所谓的“有意义”可以理解为该片段会被执行。例如语句声明了一个未使用的变量,其在编译时会被忽略,即不会被执行,所以不能作为一个程序步。

将所有程序步分别乘以它们的执行次数,即可得到程序步数。可以使用程序步数来描述 \(T_P\)。

渐进记号

\(f(n) = O(g(n))\) 即表示 \(g(n)\) 是 \(f(n)\) 的同阶或高阶无穷大。

\(f(n) = \Omega(g(n))\) 即表示 \(g(n)\) 是 \(f(n)\) 的同阶或低阶无穷大。

\(f(n) = \Theta(g(n))\) 即表示 \(g(n)\) 是 \(f(n)\) 的同阶无穷大。

显然,如果 \(f(n) = a_mn^m + ... + a_1n + a_0\),

则 \(f(n) = O(n^m)\);

若 \(a_m > 0\),

则 \(f(n) = \Omega(n^m)\),\(f(n) = \Theta(n^m)\)。

定义:

[BIG O] \(f(n) = O(g(n))\) 当且仅当存在正的常数 \(c\) 和 \(n_0\),使得对于所有的 \(n\),当 \(n \geq n_0\) 时,有 \(f(n) \leq cg(n)\)。

[\(\Omega\)] \(f(n) = \Omega(g(n))\) 当且仅当存在正的常数 \(c\) 和 \(n_0\),使得对于所有的 \(n\),当 \(n \geq n_0\) 时,有 \(f(n) \geq cg(n)\)。

[\(\Theta\)] \(f(n) = \Theta(g(n))\) 当且仅当存在正的常数 \(c_1\),\(c_2\) 和 \(n_0\),使得对于所有的 \(n\),当 \(n \geq n_0\) 时,有 \(c_1g(n) \leq f(n) \leq c_2g(n)\)。

常用的g(n)

\(1, logn, n, nlogn, n^2, n^3, 2^n, n!\)

上面一行中,每个函数均是其左侧所有函数的高阶无穷大。

举例

【例一】折半查找的时间复杂性

while 循环最多迭代 \(\lceil log_2(n+1) \rceil\) 次。所以最坏情况下,该循环将迭代 \(\Theta(logn)\) 次。

int compare(int x, int y)
{
    if (x < y) return -1;
    else if (x < y) return 1;
    else return 0;
}

int binsearch(int list[], int searchnum, int left, int right)
{
    int middle;
    while (left <= right) {
        middle = (left + right)/2;
        switch (compare(list[middle], searchnum)) {
            case -1:
                left = middle + 1;
                break;
            case 1:
                right = middle - 1;
                break;
            case 0:
                return middle;
        }
    }
    return -1;
}

考虑最坏的情况,即在 left == right 时,才找到元素。

折半查找每次都会丢弃(大约)一半的元素。将查找过程反向考虑,则类似于一个 list 中元素增加的过程,每次“查找”均使 list 元素翻倍。

假设 list 中最初只有 1 个元素,经过 x 次“查找”,最终到达 n 个元素。则:\(2^x = n\), 即 \(x = log_2(n)\)。

在此基础上,可以进一步考虑为什么 while 循环最多迭代 \(\lceil log_2(n+1) \rceil\) 次。

实际上,在这里我们并不那么关心具体的次数,具体的次数肯定是 \(log_2(n)\) 的同阶无穷大,即可得到 \(\Theta(logn)\)。

【例二】递归的全排列的时间复杂性

#define SWAP(x, y, t) ((t) = (x), (x) = (y), (y) = (t))

void perm(char *list, int i, int n)
{
    int j, temp;
    if (i == n) {
        for (j = 0; j <= n; j++) {
            printf("%c", list[j]);
        }
        printf("    ");
    }
    else {
        for (j = i; j <= n; j++) {
            SWAP(list[i], list[j], temp);
            perm(list, i + 1, n);
            SWAP(list[j], list[i], temp);
        }
    }
}

当 i == n 时,\(T_{perm}(i, n) = \Theta(n)\);

当 i < n 时,\(T_{perm}(i, n) = (n - i + 1)T_{perm}(i + 1, n)\),求解该递归方程,得 \(T_{perm}(i, n) = \Theta(n((n - i + 1)!))\)。

\(\dfrac{T(i, n)}{T(i + 1, n)}\dfrac{T(i + 1, n)}{T(i + 2, n)}...\dfrac{T(n - 2, n)}{T(n - 1, n)}\dfrac{T(n - 1, n)}{T(n, n)}\)

\(= \dfrac{T(i, n)}{T(n, n)} = \dfrac{T(i, n)}{\Theta(n)}\)

\(= (n - i + 1)(n - (i + 1) + 1)...(n - (n-2) + 1)(n - (n - 1) + 1)\)

\(= \dfrac{(n - i + 1)!}{2}\)

得 \(T_{perm}(i, n) = \Theta(n((n - i + 1)!))\)。

性能测量

利用程序生成最坏情况下的测试数据集,继而测试算法在最坏情况下的性能。

有时候利用程序生成最坏情况下的测试数据集也是非常困难的。

此时,对于关心的实例特征的一组取值,生成一个合适大小的测试数据集,得到在这些测试数据集上的运行时间,取最大值最为这组实例特征在最坏情况下的时间估计。